Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장
✒️ 2025-05-28 14:24 내용 수정
- code : https://github.com/ase10git/SpringSecurityTest
- SpringSecurity 프로젝트 설정 목록
- Spring Security 기본 사용자 추가 및 테스트
- Spring Security 프로젝트 설정 1 - DB연결과 JPA 설정
- Spring Security 프로젝트 설정 2 - JwtService와 Filter 설정
- Spring Security 프로젝트 설정 3 - Security Config
- Spring Security 프로젝트 설정 4 - Authentication Service와 Controller
- Spring Security 프로젝트 설정 5 - Security CORS 설정
- Spring Security 프로젝트 설정 6 - JWT Refresh Token 생성 및 저장
- Spring Security 프로젝트 설정 7 - JWT Refresh Token 재발급
- Spring Security 프로젝트 설정 8 - JWT 클라이언트 저장
- Spring Security 프로젝트 설정 9 - JWT 로그아웃
- Spring Security 프로젝트 설정 10 - 권한 설정
Token 관리에 관한 고민
- 참고 자료 : Learn With Ifte's Implementing Secure Refresh Tokens in Spring Boot part 1
- Access Token은 생성했는데, 클라이언트에서 이 Token을 어디에 저장해서 서버로 요청을 보낼 지 고민했다.
- 찾아보니 Access Token만 있다면 탈취 시 Token 남용 위험이 있어 Refresh Token을 생성하여 Access Token을 재발급 받는 방법이 있었다.
- 그리고 Access Token과 Refresh Token은 각각 역할이 다르고 보안 문제로 인해 클라이언트에서 저장하는 위치도 달랐다.
- Access Token은 클라이언트의 로컬 변수에 저장한다.
- Refresh Token은 요청 시
httpOnly,secure,SameSite=String/SameSite=Lax옵션을 설정한 cookie에 저장하여 전송한다. - 서버에서 Refresh Token은 DB에 저장하여 클라이언트로부터 온 Refresh Token과 비교한다.
- 따라서 서버 측에서 사용자의 첫 로그인 시에 Access Token과 Refresh Token을 생성하고, 각 Token의 만료 상태에 따른 재발급 동작을 추가한 후, Refresh Token은 DB에 저장하는 기능을 추가해야 했다.
응답 클래스 수정
- Authentication 요청에 응답으로 전달할
AuthenticationResponse클래스에refreshToken필드를 추가한다.- 기존
token필드는accessToken으로 수정한다.
- 기존
package com.example.security.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationResponse {
@JsonProperty("access_token") // json에서 표기 수정
private String accessToken;
@JsonProperty("refresh_token")
private String refreshToken;
}
Entity와 Repository 추가
- Refresh Token을 DB에 저장하기 위해
token패키지를 생성하고,TokenEntity를 생성한다.- DB에 저장할 Token에는 JWT와
User의email을 저장한다.
- DB에 저장할 Token에는 JWT와
package com.example.security.token;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Entity // Entity임을 명시
@Builder // for Object building
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "token") // DB에 테이블 이름 지정
public class Token {
@Id
private String refreshToken;
private String email;
}
- 같은 패키지에 DB와 상호작용을 담당할
TokenRepository를 생성하고,accessToken으로 조회할findByAccessToken()메소드를 추가한다.
package com.example.security.token;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface TokenRepository extends JpaRepository<Token, String> {
Optional<Token> findByRefreshToken(String token);
}
Service
JwtService수정JwtService에서 기존 Token 생성 메소드인generateToken의 매개변수에 만료 기한인long expireTime을 추가하고, Access Token과 Refresh Token을 생성하는generateAccessToken()메소드와generateRefreshToken메소드를 추가한다.SECRET-KEY,accessTokenExpiration,refreshTokenExpiration를 애플리케이션의 설정 파일에 저장하고 그 값을 가져와 관리하기 위해@ValueAnnotation을 사용하여 수정한다.- 아래 yml 파일에는 초 단위로 만료 기한을 지정했다.
accessTokenExpiration:1000*60*60*24로 1일refreshTokenExpiration:1000*60*60*24*7로 7일
package com.example.security.config;
import com.example.security.token.Token;
import com.example.security.token.TokenRepository;
import com.example.security.user.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
@RequiredArgsConstructor
public class JwtService {
// DB와 상호작용하는 token repo
private final TokenRepository tokenRepository;
@Value("${app.security.jwt.secret-key}")
private String secretKey;
// Access Token 만료기한
@Value("${app.security.jwt.access-token-expiration}")
private long accessTokenExpiration;
// Refresh Token 만료기한
@Value("${app.security.jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
// DB에 토큰 저장
public void saveUserToken(String refreshToken, User user) {
Token token = new Token(refreshToken, user.getEmail());
tokenRepository.save(token);
}
// ...
// Access 토큰 생성 - UserDetail로만 생성
public String generateAccessToken(UserDetails userDetails) {
return generateAccessToken(new HashMap<>(), userDetails);
}
// Access 토큰 생성
public String generateAccessToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails
) {
return generateToken(extraClaims, userDetails, accessTokenExpiration);
}
// Refresh 토큰 생성 - UserDetail로만 생성
public String generateRefreshToken(UserDetails userDetails) {
return generateRefreshToken(new HashMap<>(), userDetails);
}
// Refresh 토큰 생성
public String generateRefreshToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails
) {
return generateToken(extraClaims, userDetails, refreshTokenExpiration);
}
// 토큰 생성
private String generateToken(
Map<String, Object> extraClaims, // 토큰에 보낼 정보
UserDetails userDetails,
long expireTime
) {
return Jwts
.builder()
.setClaims(extraClaims) // 클레임 추가
.setSubject(userDetails.getUsername()) // subject 추가
.setIssuedAt(new Date(System.currentTimeMillis())) // 토큰 발행일
.setExpiration(new Date(System.currentTimeMillis() + expireTime)) // 만료기한
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.compact();
}
// ...
}
- application.yml 파일
# app properties
app:
security:
jwt:
secret-key: secret-key
# 1일
access-token-expiration: 86400000
# 7일
refresh-token-expiration: 604800000
AuthenticationService수정- 사용자의 회원가입과 로그인 동작에서 Access Token과 Refresh Token을 각각 생성하고, 응답 객체에 두 Token을 같이 보낸다.
- 이 때 생성한 Refresh Token은 DB에 저장하여 나중에 사용자가 Access Token 재발급을 요청할 때 전송한 Refresh Token과 비교한다.
package com.example.security.auth;
import com.example.security.config.JwtService;
import com.example.security.user.Role;
import com.example.security.user.User;
import com.example.security.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthenticationService {
// DB와 상호작용하는 사용자 repo
private final UserRepository repository;
// 비밀번호 인코더
private final PasswordEncoder passwordEncoder;
// jwt 서비스
private final JwtService jwtService;
// 사용자 신원 확인
private final AuthenticationManager authenticationManager;
// 회원가입
public AuthenticationResponse register(RegisterRequest request) {
// 요청으로부터 온 데이터로 사용자 객체 생성
var user = User.builder()
.firstname(request.getFirstname())
.lastname(request.getLastname())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.role(Role.USER)
.build();
// 사용자 저장
repository.save(user);
// 토큰 생성 - 사용자 정보로 생성
var accessToken = jwtService.generateAccessToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
// 토큰을 db에 저장
jwtService.saveUserToken(refreshToken, user);
// 인증 응답 객체 생성
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// 인증 확인
public AuthenticationResponse authenticate(AuthenticationRequest request) {
// 요청으로 들어온 사용자의 신원 확인
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
// 위의 인증을 거친 사용자를 DB에 검색
var user = repository.findByEmail(request.getEmail())
.orElseThrow();
// 토큰 생성 - 사용자 정보로 생성
var accessToken = jwtService.generateAccessToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
// 토큰을 db에 저장
jwtService.saveUserToken(refreshToken, user);
// 인증 응답 객체 생성
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
Test
- 애플리케이션 실행 후 POSTMAN이나 Talent API에서
http://localhost:port/api/v1/auth/register로 회원가입 요청을 보낸 후 response를 확인하면access_token과refresh_token을 확인할 수 있다.
- 이번엔
http://localhost:port/api/v1/auth/authenticate로 로그인 요청을 보냈을 때 마찬가지로access_token과refresh_token응답을 확인할 수 있다.
- DB를 실행하여
token테이블에 데이터가 저장되었는지 확인하여 DB와도 연동이 잘 되는지 확인한다.- Token 저장은 session 저장용으로 많이 사용하는 Redis와도 연동해도 괜찮을 것 같다.
- https://jwt.io/ 에서
access_token과refresh_token을 해독하여 사용자와 만료 기한이 잘 입력 되어 있는지 확인한다.